package play.modules.facebook;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;

import play.Logger;
import play.Play;
import play.exceptions.UnexpectedException;
import play.libs.WS;
import play.libs.WS.HttpResponse;
import play.mvc.Http.Cookie;
import play.mvc.Http.Request;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.restfb.DefaultFacebookClient;
import com.restfb.FacebookClient;

/**
 * FbGraph provides simple access to the Facebook Graph API.
 *
 * @author Eric Jacob
 * @author <a href="http://bluxte.net/">Sylvain Wallez</a> update to the OAuth 2.0 scheme
 */
public class FbGraph {

    public final static String FB_GRAPH_URL = "https://graph.facebook.com/";
    private static String appId;
    private static String appSecret;

    public static void init() {
        if (!Play.configuration.containsKey("fbg.appId")) {
            throw new UnexpectedException("Module FbGraph requires that you specify fbg.appId in your application.conf");
        }
        if (!Play.configuration.containsKey("fbg.appSecret")) {
            throw new UnexpectedException("Module FbGraph requires that you specify fbg.appSecret in your application.conf");
        }
        appId = Play.configuration.getProperty("fbg.appId");
        appSecret = Play.configuration.getProperty("fbg.appSecret");
    }

    /**
     * Returns the Id associated to this application.
     *
     * @return  the application Id
     */
    public static String getAppId() {
        return appId;
    }

    /**
     * Returns the Secret associated to this application.
     *
     * @return  the application Secret
     */
    public static String getAppSecret() {
        return appSecret;
    }

    /**
     * Returns the data stored in the Facebook session associated with the user.
     * This is a JSON object that contains the facebook user id, the access token
     * and other information used less often. See the full details at
     * <a href="https://developers.facebook.com/docs/authentication/signed_request/">
     * https://developers.facebook.com/docs/authentication/signed_request/</a>
     *
     * @return  the data stored in the Facebook cookie
     *          or null if no Facebook session associated with the user
     */
    public static JsonObject getFacebookCookie() {
        Request request = Request.current();
        // Value is cached in the request args since recreating it is costly
        JsonObject fbData = (JsonObject)request.args.get("FbGraph.cookie");
        if (fbData == null) {
            Cookie cookie = Request.current().cookies.get("fbsr_" + getAppId());
            if (cookie != null) {
                fbData = getFacebookInfo(cookie.value);
                request.args.put("FbGraph.cookie", fbData);
            }
        }

        return fbData;
    }

    /**
     * Actually parse the Facebook cookie
     *
     * @param cookieValue the fbsr_[app_id] cookie value
     * @return the JsonObject, or null if incorrect
     */
    public static JsonObject getFacebookInfo(String cookieValue) {
        JsonObject fbData = null;

        String[] values = cookieValue.split("\\.", 2);
        Base64 base64 = new Base64(true); // use url-safe variant

        String jsonData = new String(base64.decode(values[1]));
        fbData = (JsonObject)new JsonParser().parse(jsonData);

        try {
            // Check that we have the expected algorithm
            String cipher = fbData.get("algorithm").getAsString();
            if (!"HMAC-SHA256".equals(cipher)) {
                Logger.error("FbGraph: unexpected signature algorithm: %s" , cipher);
                return null;
            }

            // Compute the signature with our secret key
            SecretKeySpec skeySpec = new SecretKeySpec(getAppSecret().getBytes("UTF-8"), "HmacSHA256");
            Mac hmac = Mac.getInstance("HmacSHA256");
            hmac.init(skeySpec);

            byte[] sig = hmac.doFinal(values[1].getBytes("UTF-8"));
            if (!Arrays.equals(sig, base64.decode(values[0]))) {
                // Invalid signature. Did someone try to tamper the cookie?
                Logger.error("FbGraph: wrong signature for %s" , jsonData);
                return null;
            }

        } catch (Exception e) {
            Logger.error(e, "FbGraph: error while checking signature");
            return null;
        }

        return fbData;
    }

    /**
     * Returns the user access token associated with this application.
     *
     * @return  the user access token
     *          or null if no Facebook session associated with the current request
     */
    public static String getAccessToken() {
        Request request = Request.current();
        // Since this requires calling a remote URL, cache it in the request args
        String token = (String)request.args.get("FbGraph.token");
        if (token == null) {
            JsonObject json = getFacebookCookie();
            if (json == null || !json.has("code")) {
                return null;
            }

            // See https://developers.facebook.com/docs/authentication/
            String url = "https://graph.facebook.com/oauth/access_token?client_id=" + FbGraph.getAppId() +
                    "&redirect_uri=" + // empty since we use the fb:login button from the JS-SDK
                    "&client_secret=" + FbGraph.getAppSecret() + "&code=" + json.get("code").getAsString();

            String response = WS.url(url).get().getString();
            // The response has the form access_token={actual token}&expires=5497
            // "expires" may not be present if your have requested offline access
            String[] parts = response.split("[=&]");

            if (parts.length < 2) {
                Logger.warn("FbGraph: can't get access token: %s", response);
            }

            token = parts[1];

            request.args.put("FbGraph.token", token);
        }

        return token;
    }

    /**
     * Returns the Facebook user id.
     *
     * @return the facebook user id
     *         or null if no Facebook session is associated with the current request
     */
    public static String getUserId() {
        JsonObject json = getFacebookCookie();
        if (json != null && json.has("user_id")) {
            return json.get("user_id").getAsString();
        } else {
            return null;
        }
    }

    /**
     * Executes a GET or POST request to the Graph API.
     *
     * @param   path - the URL path
     * @param   method - the HTTP method (optional, default "GET")
     * @param   params - the parameters for the query (optional)
     * @return  the HTTP response
     */
    private static HttpResponse makeRequest(String path, String method, Map<String, String> params) {
        StringBuilder url = new StringBuilder();
        url.append(FB_GRAPH_URL);
        if (path.startsWith("/")) {
            path = path.substring(1);
        }
        url.append(path);
        StringBuilder queryStr = new StringBuilder();
        for (Map.Entry<String, String> param : params.entrySet()) {
            if (queryStr.length() > 0) {
                queryStr.append("&");
            }
            queryStr.append(WS.encode(param.getKey()));
            queryStr.append("=");
            queryStr.append(WS.encode(param.getValue()));
        }
        if (method != null && method.toUpperCase().equals("POST")) {
            return WS.url(url.toString()).body(queryStr.toString()).mimeType("multipart/form-data").post();
        } else {
            url.append("?");
            url.append(queryStr.toString());
            return WS.url(url.toString()).get();
        }
    }

    /**
     * Performs an authorized request to the Graph API.
     *
     * @param   path - the URL path
     * @param   method - the HTTP method (optional, default "GET")
     * @param   params - the parameters for the query (optional)
     * @return  the HTTP response
     */
    private static HttpResponse oauthRequest(String path, String method, Map<String, String> params) {
        if (params == null) {
            params = new HashMap<String, String>();
        }
        if (!params.containsKey("access_token")) {
            params.put("access_token", getAccessToken());
        }
        return makeRequest(path, method, params);
    }

    /**
     * Executes an API call to the Graph API.
     *
     * @param   path - the URL path, e.g. "me/friends"
     * @param   method - the HTTP method (optional, default "GET")
     * @param   params - the parameters for the query (optional)
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonElement api(String path, String method, Map<String, String> params)
            throws FbGraphException {
        if (getFacebookCookie() == null) {
            throw new FbGraphException("No Facebook session associated with the user");
        }
        HttpResponse resp = oauthRequest(path, method, params);
        if (resp == null) {
            throw new UnexpectedException("Module FbGraph got an unexpected response from facebook");
        }
        if (resp.getStatus() != 200) {
            throw new FbGraphException("HttpResponse", Integer.toString(resp.getStatus()), resp.getString());
        }
        JsonElement json = resp.getJson();
        if (json.isJsonObject()) {
            JsonObject jsonObject = (JsonObject) json;
            if (jsonObject.get("error") != null) {
                throw new FbGraphException(jsonObject);
            }
        }
        return json;
    }

    /**
     * Executes an API call to the Graph API.
     *
     * @param   path - the URL path, e.g. "me/friends"
     * @param   method - the HTTP method (optional, default "GET")
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonElement api(String path, String method)
            throws FbGraphException {
        return api(path, method, new HashMap<String, String>());
    }

    /**
     * Executes an API call to the Graph API.
     *
     * @param   path - the URL path, e.g. "me/friends"
     * @param   params - the parameters for the query (optional)
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonElement api(String path, Map<String, String> params)
            throws FbGraphException {
        return api(path, "GET", params);
    }

    /**
     * Executes an API call to the Graph API.
     *
     * @param   path - the URL path, e.g. "me/friends"
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonElement api(String path) throws FbGraphException {
        return api(path, "GET", new HashMap<String, String>());
    }

    /**
     * Fetches a single object to the Graph API.
     *
     * @param   objId - the ID of the object, e.g. "me"
     * @param   params - the parameters for the query (optional)
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonObject getObject(String objId, Map<String, String> params)
            throws FbGraphException {
        return api(objId, params).getAsJsonObject();
    }

    /**
     * Fetches a single Graph API object.
     *
     * @param   objId - the ID of the object, e.g. "me"
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonObject getObject(String objId) throws FbGraphException {
        return getObject(objId, new HashMap<String, String>());
    }

    /**
     * Fetches a single object to the Graph API.
     *
     * @param   objId - the ID of the object, e.g. "me"
     * @param   clazz - the object type
     * @param   params - the parameters for the query (optional)
     * @return  the response object
     * @throws  FbGraphException
     */
    public static <T> T getObject(String objId, Class<T> clazz, Map<String, String> params)
            throws FbGraphException {
        return JsonUtil.toJavaObject(getObject(objId, params), clazz);
    }

    /**
     * Fetches a single Graph API object.
     *
     * @param   objId - the ID of the object, e.g. "me"
     * @param   clazz - the object type
     * @return  the response object
     * @throws  FbGraphException
     */
    public static <T> T getObject(String objId, Class<T> clazz)
            throws FbGraphException {
        return getObject(objId, clazz, new HashMap<String, String>());
    }

    /**
     * Fetches multiple Graph API objects.
     *
     * @param   ids - the IDs of the objects
     * @return  the response objects
     * @throws  FbGraphException
     */
    public static JsonObject getObjects(Map<String, String> ids)
            throws FbGraphException {
        return getObject("", ids);
    }

    /**
     * Fetches a Graph API connection.
     *
     * @param   conId - the ID/CONNECTION_TYPE string, e.g. "me/friends"
     * @param   params - the parameters for the query (optional)
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonArray getConnection(String conId, Map<String, String> params)
            throws FbGraphException {
        return api(conId, params).getAsJsonObject().get("data").getAsJsonArray();
    }

    /**
     * Fetches a Graph API connection.
     *
     * @param   conId - the ID/CONNECTION_TYPE string, e.g. "me/friends"
     * @return  the response object
     * @throws  FbGraphException
     */
    public static JsonArray getConnection(String conId)
            throws FbGraphException {
        return getConnection(conId, new HashMap<String, String>());
    }

    /**
     * Fetches a Graph API connection.
     *
     * @param   conId - the ID/CONNECTION_TYPE string, e.g. "me/friends"
     * @param   clazz - the object type
     * @param   params - the parameters for the query (optional)
     * @return  the response object
     * @throws  FbGraphException
     */
    public static <T> List<T> getConnection(String conId, Class<T> clazz, Map<String, String> params)
            throws FbGraphException {
        return JsonUtil.toJavaObject(getConnection(conId, params), clazz);
    }

    /**
     * Fetches a Graph API connection.
     *
     * @param   conId - the ID/CONNECTION_TYPE string, e.g. "me/friends"
     * @param   clazz - the object type
     * @return  the response object
     * @throws  FbGraphException
     */
    public static <T> List<T> getConnection(String conId, Class<T> clazz)
            throws FbGraphException {
        return getConnection(conId, clazz, new HashMap<String, String>());
    }

    /**
     * Returns a picture URL.
     *
     * @param   picId - the ID of the picture
     * @return  the URL of the picture
     * @throws  FbGraphException
     */
    public static String getPicture(String picId) throws FbGraphException {
        if (getFacebookCookie() == null) {
            throw new FbGraphException("No Facebook session associated with the user");
        }
        return FB_GRAPH_URL + picId + "/picture?access_token=" + WS.encode(getAccessToken());
    }

    /**
     * Returns a picture URL.
     *
     * @param   picId - the ID of the picture
     * @param   picType - the size of the picture
     * @return  the URL of the picture
     * @throws  FbGraphException
     */
    public static String getPicture(String picId, String picType)
            throws FbGraphException {
        return getPicture(picId) + "&type=" + picType;
    }

    /**
     * Performs a Graph API publish operation.
     *
     * @param   path - the URL path
     * @param   params - the parameters of the post
     * @return  the published Facebook graph object
     * @throws  FbGraphException
     */
    public static JsonElement publish(String path, Map<String, String> params)
            throws FbGraphException {
        return api(path, "POST", params);
    }

    /**
     * Performs a Graph API publish operation.
     *
     * @param   path - the URL path
     * @param   params - the parameters of the post
     * @param   clazz - the object type
     * @return  the published Facebook graph object
     * @throws  FbGraphException
     */
    public static <T> T publish(String path, Class<T> clazz, Map<String, String> params)
            throws FbGraphException {
        return JsonUtil.toJavaObject(publish(path, params).toString(), clazz);
    }

    /**
     * Performs a Graph API delete operation.
     *
     * @param   objId - the ID of the object
     * @return  true if successful, false otherwise
     * @throws  FbGraphException
     */
    public static Boolean delete(String objId)
            throws FbGraphException {
        return api(objId, "POST", Parameter.with("method", "delete").parameters()).getAsBoolean();
    }

    /**
     * Returns a RestFB Facebook client.
     *
     * @return  the Facebook client
     */
    public static FacebookClient getFacebookClient() {
        return getFacebookClient(getAccessToken());
    }

    /**
     * Returns a RestFB Facebook client.
     *
     * @param   accessToken - the user access token
     * @return  the Facebook client
     */
    public static FacebookClient getFacebookClient(String accessToken) {
        return new DefaultFacebookClient(accessToken);
    }
}
